🔗 PR - https://github.com/grpc/grpc-go/pull/8375
1. PR의 목적
기존에 작성되지 않았던 ExitIdle 관련 메서드의 단위 테스트를 작성하여 테스트 커버리를 추가하는 PR 입니다. 여기서 ExitIdle 이 무엇을 의미하는지 아래에서 설명하겠습니다. 이번 PR 에서는 BalancerGroup 의 ExitIdle 이 다양한 시나리오에서 올바르게 처리되는지 검증하기 위한 테스트 코드를 추가합니다.
1-1. ExitIdle 이란?
ExitIdle은 gRPC 클라이언트가 유휴(idle) 상태에서 벗어나(exit) 다시 연결을 활성화하는 메커니즘을 의미합니다. gRPC 클라이언트는 서버와의 연결을 유지하지만, 일정 시간 동안 요청이 없으면 리소스를 절약하기 위해 연결이 유휴 상태로 전환됩니다. 유휴 상태에서는 서버와의 연결이 끊기거나, 요청이 왔을 때 즉시 처리할 수 없는 상태가 될 수 있습니다. ExitIdle은 새로운 요청이 들어왔을 때, 클라이언트가 유휴 상태에서 벗어나 서버와의 연결을 재개하고, 정상적인 요청 처리 상태로 돌아가도록 트리거합니다.
subConn 은 gRPC client 와 하나의 특정 서버 간의 단일 연결을 의미하는 개념으로, IDLE 상태의 SubConn 은 subConn.Connect가 호출될 때까지 자동으로 재연결하지 않습니다.
gRPC 의 default idleTimeout 은 30분으로 채널에 진행 중인 RPC가 없고 새로운 RPC가 시작되지 않으면 채널은 idle 모드로 전환됩니다.
이때 name resolver와 load balancer가 종료됩니다.
반대로 ExitIdle 이 트리거 되면 name resolver 와 load balancer 역시 재시작 됩니다.
gofunc defaultDialOptions() dialOptions { return dialOptions{ copts: transport.ConnectOptions{ ReadBufferSize: defaultReadBufSize, WriteBufferSize: defaultWriteBufSize, UserAgent: grpcUA, BufferPool: mem.DefaultBufferPool(), }, bs: internalbackoff.DefaultExponential, idleTimeout: 30 * time.Minute, defaultScheme: "dns", maxCallAttempts: defaultMaxCallAttempts, useProxy: true, enableLocalDNSResolution: false, } }
gRPC 의 channel 이란
`채널(channel)` 은 클라이언트가 서버에 RPC(remote procedure call) 를 보낼 수 있는 통신 통로 역할을 하는 논리적 연결을 의미합니다. 여기서 채널은 단순히 하나의 tcp 연결을 의미하는 것이 아닌, 여러 기능을 추상화하고 관리하는 상위 개념으로 아래의 역할을 수행할 수 있습니다.
- 연결 관리 : 채널은 한 개 이상의 물리적인 TCP 연결(subConn) 을 관리
- 부하 분산 : 여러 서버 인스턴스에 부하 분산을 처리
- 연결 재시도: 연결이 끊어졌을 때 자동 재연결 시도.
gotype ExitIdler interface { // ExitIdle은 LB 정책에게 백엔드와 재연결하거나 // IDLE 상태에서 벗어나도록 지시합니다 ExitIdle() }
정리하자면 ExitIdle 은 유휴 상태인 클라이언트가 활성화되어 재요청을 보내는 트리거 역할을 제공합니다.
1-2. BalancerGroup 이란?
balancergroup은 gRPC 클라이언트 측에서 여러 백엔드 연결을 관리하고 요청을 적절한 백엔드로 분산하기 위한 부하 분산 구성 요소입니다.
하나의 gRPC 서비스는 여러 서버(백엔드)로 구성될 수 있으며, balancergroup 은 이러한 서버를 그룹화하여 효율적인 부하 분산을 가능케 합니다.
클라이언트와 통신할 여러 서버 중에서 현재 요청을 보낼 최적의 서버를 선택하는 로직을 구현하며, 이는 round robin,least-connection 같은 다양한 부하 분산 전략을 선택할 수 있습니다.
balancergroup 은 클라이언트-서버 간 여러 개의 물리적 연결을 논리적인 하나의 단위로 관리하여, 로드 밸런싱 정책이 복잡한 연결 상태를 개발자가 직접 다루지 않고도 추상화된 백엔드 그룹에 대한 결정을 내릴 수 있는 역할을 합니다.
ExitIdle 과 BalancerGroup 는 클라이언트 측 로드 밸런싱에서 함께 동작합니다. 유휴 상태의 클라이언트가 활성화되어 요청을 보내기 위한 트리거 역할을 ExitIdle 이 제공하고,
BalancerGroup 은 활성화된 클라이언트가 요청을 보낼 때 최적의 서버를 선택할 있도록 부하 분산 작업을 설정 역할을 수행합니다.
2. PR - 테스트 케이스 작성
BalancerGroup 관련 테스트 작성:
2-1. TestBalancerGroup_UpdateClientConnState_AfterClose
BalancerGroup이 닫힌(Close) 후 UpdateClientConnState 메서드가 호출됐을 때의 동작을 검증하는 테스트로, BalancerGroup close 후에는 더 이상 하위 balancer 에게 상태 업데이트 전파가 되지 않도록 검증합니다.
gofunc (s) TestBalancerGroup_UpdateClientConnState_AfterClose(t *testing.T) { balancerName := t.Name() clientConnStateCh := make(chan struct{}, 1) // stub balancer 등록 stub.Register(balancerName, stub.BalancerFuncs{ // UpdateClientConnState 호출될 때 마다 채널에 신호를 보내도록 함 UpdateClientConnState: func(_ *stub.BalancerData, _ balancer.ClientConnState) error { clientConnStateCh <- struct{}{} return nil }, }) bg := New(Options{ CC: testutils.NewBalancerClientConn(t), // 테스트용 클라이언트 연결 생성 BuildOpts: balancer.BuildOptions{}, StateAggregator: nil, Logger: nil, }) bg.Add(testBalancerIDs[0], balancer.Get(balancerName)) bg.Close() // BalancerGroup 을 종료 상태로 변경 -> bg 에 속하는 balancer 는 유휴 상태가 되어야 함 if err := bg.UpdateClientConnState(testBalancerIDs[0], balancer.ClientConnState{}); err != nil { t.Fatalf("Expected nil error, got %v", err) } select { case <-clientConnStateCh: t.Fatalf("UpdateClientConnState was called after BalancerGroup was closed") case <-time.After(defaultTestShortTimeout): } }
UpdateClientConnState 를 호출하면 clientConnStateCh 채널에 신호가 오도록 stub 을 등록했고, select 문을 사용하여 해당 채널에 신호가 오는지 혹은 defaultTestShortTimeout 이 지나면 정상종료 되도록 두가지 케이스를 검증합니다. 종료된 BalancerGroup 에서 UpdateClientConnState 를 호출할 수 없는 것이 정상 동작이기 때문에 clientConnStateCh 가 신호를 받으면 테스트는 실패합니다. 실제로 UpdateClientConnState 구현을 보면 balancer group 이 닫힌 후엔 nil 을 리턴하여 graceful 하게 처리되는 것을 확인할 수 있습니다.
go// UpdateClientConnState handles ClientState (including balancer config and // addresses) from resolver. It finds the balancer and forwards the update. func (bg *BalancerGroup) UpdateClientConnState(id string, s balancer.ClientConnState) error { bg.outgoingMu.Lock() defer bg.outgoingMu.Unlock() if bg.outgoingClosed { return nil } if config, ok := bg.idToBalancerConfig[id]; ok { return config.updateClientConnState(s) } return nil }
2-2. TestBalancerGroup_ResolverError_AfterClose
BalancerGroup이 닫힌 후 ResolverError 메서드가 호출 시 동작을 검증하는 테스트로
BalancerGroup close 후에는 더 이상 하위 balancer 에게 resolver 에러가 전파되지 않도록 검증합니다.
ResolverError는 gRPC의 name resolution 과정에서 발생하는 에러를 처리하는 메서드입니다.
DNS 조회 실패, Service Discovery 장애, 네트워크 연결 문제 등의 상황에서 name resolver가 서비스 이름을 실제 서버 주소로 변환하지 못할 때 호출됩니다.
ResolverError를 호출하면 resolveErrorCh 채널에 신호가 오도록 stub을 등록했고
select 문을 사용하여 해당 채널에 신호가 오는지 혹은 defaultTestShortTimeout이 지나면 정상종료 되도록 두가지 케이스를 검증합니다.
종료된 BalancerGroup 에서 ResolverError가 하위 balancer에 전파되지 않는 것이 정상 동작이기 때문에 resolveErrorCh가 신호를 받으면 테스트는 실패합니다.
gofunc (s) TestBalancerGroup_ResolverError_AfterClose(t *testing.T) { balancerName := t.Name() resolveErrorCh := make(chan struct{}, 1) stub.Register(balancerName, stub.BalancerFuncs{ ResolverError: func(_ *stub.BalancerData, _ error) { resolveErrorCh <- struct{}{} }, }) bg := New(Options{ CC: testutils.NewBalancerClientConn(t), BuildOpts: balancer.BuildOptions{}, StateAggregator: nil, Logger: nil, }) bg.Add(testBalancerIDs[0], balancer.Get(balancerName)) bg.Close() bg.ResolverError(errors.New("test error")) select { case <-resolveErrorCh: t.Fatalf("ResolverError was called on sub-balancer after BalancerGroup was closed") case <-time.After(defaultTestShortTimeout): } }
ResolverError 구현에선 balancer group 이 닫힌 후엔 early return 으로 graceful 한 처리하는 것을 확인할 수 있습니다.
go// ResolverError handles name resolution errors from the name resolver. func (bg *BalancerGroup) ResolverError(err error) { bg.outgoingMu.Lock() defer bg.outgoingMu.Unlock() if bg.outgoingClosed { return } for _, config := range bg.idToBalancerConfig { config.resolverError(err) } }
2-3. TestBalancerGroup_ExitIdle_AfterClose
BalancerGroup이 닫힌 후 ExitIdle 메서드가 호출됐을 때의 동작을 검증하는 테스트 입니다.
BalancerGroup close 후에는 더 이상 하위 balancer 에게 ExitIdle 호출이 전파되지 않도록 검증합니다.
ExitIdle 의 개념을 파악하면 사실 너무나 당연한 검증 코드입니다. ExitIdle은 유휴 상태의 클라이언트를 활성화하여 서버와의 연결을 재개하는 것이 핵심 메커니즘으로, 일반적으로는 새로운 RPC 요청이 들어오거나 명시적으로 Connect() 메서드가 호출될 때 트리거되어 IDLE 상태의 SubConn 들이 연결을 시도합니다.
gofunc (s) TestBalancerGroup_ExitIdle_AfterClose(t *testing.T) { balancerName := t.Name() exitIdleCh := make(chan struct{}, 1) stub.Register(balancerName, stub.BalancerFuncs{ ExitIdle: func(_ *stub.BalancerData) { exitIdleCh <- struct{}{} }, }) bg := New(Options{ CC: testutils.NewBalancerClientConn(t), BuildOpts: balancer.BuildOptions{}, StateAggregator: nil, Logger: nil, }) bg.Add(testBalancerIDs[0], balancer.Get(balancerName)) bg.Close() bg.ExitIdle() select { case <-exitIdleCh: t.Fatalf("ExitIdle was called on sub-balancer even after BalancerGroup was closed") case <-time.After(defaultTestShortTimeout): } }
ExitIdle을 호출하면 exitIdleCh 채널에 신호가 오도록 stub을 등록했고, select 문을 사용하여 해당 채널에 신호가 오는지 혹은 defaultTestShortTimeout이 지나면 정상종료 되도록 두가지 케이스를 검증합니다. 종료된 BalancerGroup에서 ExitIdle이 하위 balancer에 전파되지 않는 것이 정상 동작이기 때문에 exitIdleCh가 신호를 받으면 테스트는 실패합니다.
실제로 ExitIdle 구현을 보면 balancer group이 닫힌 후엔 early return 후 graceful 하게 처리되는 것을 확인할 수 있습니다.
go// ExitIdle starts the balancing picker for use and exits idle mode. func (bg *BalancerGroup) ExitIdle() { bg.outgoingMu.Lock() defer bg.outgoingMu.Unlock() if bg.outgoingClosed { return } for _, config := range bg.idToBalancerConfig { config.exitIdle() } }
위 3개의 테스트코드와 유사한 메커니즘으로 나머지 상황에 대해서도 테스트 코드를 작성했고 PR 을 마무리 할 수 있었습니다.
3. 마무리
grpc-go 내부 메커니즘 중 BalancerGroup 과 ExitIdle 의 동작원리를 이해하고 다양한 시나리오에 대한 테스트 코드를 작성할 수 있는 PR 이었습니다.
리뷰를 받기 전 channel 대신 variable 을 사용해 검증 코드를 작성했을 땐 다음과 같은 리뷰를 받게 됐습니다.
간단히 말하면 변수 대신 채널을 사용하란 말인데, grpc 처럼 내부적으로 많은 고루틴을 사용하는 라이브러리를 테스트할 땐 race condition 에 대한 고려도
필요하기 때문에 내부적으로 동기화가 보장되는 channel 을 사용해야 한다는 것도 알 수 있었습니다.